/**
 * 
 */
package jpb;

import jpb.hotspot.HotspotJvm;
import net.jcip.annotations.NotThreadSafe;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.util.*;

import static jpb.Assert.notNull;

/**
 * <p>
 * A builder class designed to facilitate starting of external Java processes
 * from within a JVM, in a similar manner to the general-purpose
 * {@link ProcessBuilder} in the JDK.
 *
 * <p>
 * This class provides a thin layer around the {@link ProcessBuilder} to
 * specialize it into spawning JVM processes.
 *
 * <p>
 * Beyond the standard process-oriented API (all of them provided by this builder class) this builder features the following
 * settings:
 * <ul>
 * <li>entry point into the Java application ({@link #entryPoint(EntryPoint)})
 * <li>application arguments ({@link #applicationArguments(List)} and related methods)
 * <li>class path ({@link #classPath(List)}
 * <li>standard JVM memory parameters ({@link #minMemory()},
 * {@link #maxMemory()}, {@link #maxStackSize()}
 * <li>JVM system properties ({@link #setSystemProperty(String, String)}, {@link #systemProperties()})
 * <li>custom JVM implementation to provide appropriate evaluation of the above
 * variables into a valid executable command
 * </ul>
 *
 * <p>
 * Instances of this is class are <i>not</i> meant to be accessed by multiple threads.
 *
 * @see ProcessBuilder
 * @author Dariusz Kordonski
 */
@NotThreadSafe
public final class JavaProcessBuilder {

    private static final Logger logger = LoggerFactory.getLogger(JavaProcessBuilder.class);
    
	private final ProcessBuilder builder;
	private final Jvm jvm;

    private StartupCommandSelector commandSelector;

	private EntryPoint entryPoint;
	private List<File> classPath = new ArrayList<File>();
	private List<String> applicationArgs = new ArrayList<String>();

    // use map with validating JvmArg
    private final Map<String,String> systemProperties = new StrictMap<String, String>(new HashMap<String, String>());
	
	private MemArg maxStackSize;
	private MemArg minMemory;
	private MemArg maxMemory;

    private final Map<JvmArg<?>, Object> customArgs = new HashMap<JvmArg<?>, Object>();

    private List<String> commands;

	/**
     * Main constructor for a specific JVM implementation representation
     * that will convert startup parameters into a JVM-specific
     * command line string.
     * 
     * @param jvm - a specific JVM representation
     */
    public JavaProcessBuilder(Jvm jvm) {
        this.jvm = notNull("jvm", jvm);
        this.builder =  new ProcessBuilder();
        this.commandSelector = jvm.commandSelector();
    }

	/**
	 * Default constructor for Hotspot JVM.
	 *
	 * @see jpb.hotspot.HotspotJvm
	 */
	public JavaProcessBuilder() {
	    this(new HotspotJvm());
	}

	/**
     * <p>
	 * Starts the Java process according to the current configuration
	 * of this builder.
     *
     * <p>
	 * Since this is just a special case of the {@link ProcessBuilder#start()},
	 * all restrictions of that operation apply equally here.
	 *
	 * @return {@link Process} instance representing the started Java process
	 * @throws IOException if the process fails to start due to underlying
	 * OS failure
	 * @throws IllegalStateException if conversion of Java-specific parameters
	 * for this process fails
     * @see ProcessBuilder#start()
	 */
	public Process start() throws IOException {
        setUpCommands();
	    logStartCommand();
	    return builder.command(commands).start();
	}

    /**
     * <p>
     * For testing/debugging purposes. Set up commands, as if this builder was about to start a process.
     * Don't start the process, just return the commands list, as it would get passed to the underlying process builder.
     *
     * @return command list equivalent to the commands that would get passed to the underlying builder if
     * {@link #start()} was called
     */
    List<String> dryRun() {
        setUpCommands();
        return new ArrayList<String>(commands);
    }

    private void setUpCommands() {
        resetCommands();
        addClassPathToCmds();
        addMinMemoryToCmds();
        addMaxMemoryToCmds();
        addMaxStackSizeToCmds();
        addCustomArgsToCmds();
        addSystemPropertiesToCmds();
        addEntryPointToCmds();
        addApplicationArgs();
    }

    private void resetCommands() {
        commands = new ArrayList<String>();
        CommandArg starter = commandSelector.command();
        if (starter == null || starter.isEmpty()) {
            throw new IllegalStateException("StartupCommandSelector <" + commandSelector + "> returned an empty command");
        }
        addCmd(starter);
    }

    private void addClassPathToCmds() {
        if (classPath != null && !classPath.isEmpty()) {
            addCmd(jvm.classPathArg().convert(classPath()));
        }
    }


	private void addMinMemoryToCmds() {
	    if (minMemory != null) {
	        addCmd(jvm.minMemoryArg().convert(minMemory));
	    }
	}

    private void addMaxMemoryToCmds() {
	    if (maxMemory != null) {
	        addCmd(jvm.maxMemoryArg().convert(maxMemory));
	    }
	}

    private void addMaxStackSizeToCmds() {
	    if (maxStackSize != null) {
	        addCmd(jvm.maxStackSizeArg().convert(maxStackSize));
	    }
	}
    private void addCustomArgsToCmds() {
        for (Map.Entry<JvmArg<?>,Object> customArg : customArgs.entrySet()) {
            @SuppressWarnings("unchecked") JvmArg<Object> jvmArg = (JvmArg<Object>) customArg.getKey();
            addCmd(jvmArg.convert(customArg.getValue()));
        }
    }
	private void addSystemPropertiesToCmds() {
        if (!systemProperties.isEmpty()) {
            // TODO for better performance we might check if Jvm is not Hotspot - no defense needed
            addCmd(jvm.systemPropertiesArg().convert(Collections.unmodifiableMap(systemProperties)));
        }
    }

    private void addEntryPointToCmds() {
        addCmd(jvm.entryPointArg().convert(entryPoint));
	}

	private void addCmd(CommandArg cmd) {
	    cmd.addTo(commands);
	}

    private void addApplicationArgs() {
        commands.addAll(this.applicationArgs);
    }

    private void logStartCommand() {
        if (logger.isDebugEnabled()) {
            logger.debug("About to execute java process for command list: " + commands.toString());
        }
    }

    /**
     * Set custom startup command selector for this builder. This will override the default selector provided by
     * the underlying {@link Jvm}.
     *
     * @param newCommandSelector new command selector for the Java processes started by this builder
     * @return this builder instance
     * @see jpb.StartupCommandSelector
     * @see jpb.Jvm#commandSelector() 
     */
    public JavaProcessBuilder commandSelector(StartupCommandSelector newCommandSelector) {
        this.commandSelector = notNull("newCommandSelector", newCommandSelector);
        return this;
    }

    /**
     * Current command selector of this builder.
     *
     * @return startup command selector
     * @see jpb.StartupCommandSelector
     */
    public StartupCommandSelector commandSelector() {
        return commandSelector;
    }


	/**
	 * Provide an entry point into the Java process that is about to start via this builder.
	 *
	 * @param newEntryPoint entry point instance
	 * @return this builder instance
	 */
	public JavaProcessBuilder entryPoint(EntryPoint newEntryPoint) {
        this.jvm.entryPointArg().validate(newEntryPoint);
	    this.entryPoint = notNull("entryPoint", newEntryPoint);
	    return this;
	}

	/**
	 * Current Java entry point associated with this builder
	 * 
	 * @return entry point
     * @see jpb.EntryPoint
	 */
	public EntryPoint entryPoint() {
	    return this.entryPoint;
	}
	
	
	/**
	 * Set classpath for the Java processes started by this builder
	 * 
	 * @param newClassPath
     *                  - new class path
     * @return this builder instance
	 */
	public JavaProcessBuilder classPath(List<File> newClassPath) {
        this.jvm.classPathArg().validate(newClassPath);
	    this.classPath = new ArrayList<File>(newClassPath);
	    return this;
	}
	
	/**
	 * Add <tt>classPathElement</tt> to this builder's class path
	 * 
	 * @param classPathElement file to add to the class path
	 * @return this builder instance
	 */
	public JavaProcessBuilder addToClassPath(File classPathElement) {
        this.jvm.classPathArg().validate(Arrays.asList(classPathElement));
        this.classPath.add(classPathElement);
	    return this;
	}
	
	/**
     * <p>
	 * Class path of this builder.
	 *
     * <p>
     * The returned list is a copy of the underlying class path list, so changes to it <b>will not</b>
     * change the state of this builder. To that end, use {@link #classPath(java.util.List)} and
     * {@link #addToClassPath(java.io.File)} instead.
     *
	 * @return list of files representing the class path of the Java process about to start by this builder
	 */
	public List<File> classPath() {
	    return new ArrayList<File>(this.classPath);
	}

	
	
    /**
     * Set working directory for the new Java process.
     * 
     * @param workingDir
     *            - file representing the new working directory of the process
     * @return this builder instance
     * @see ProcessBuilder#directory(java.io.File) 
     */
	public JavaProcessBuilder workingDirectory(File workingDir) {
		builder.directory(workingDir);
		return this;
	}
	
	/**
	 * Get working directory for the new Java process
	 * 
	 * @return file representing working directory for the new Java process
     * @see ProcessBuilder#directory()
	 */
	public File workingDirectory() {
		return builder.directory();
	}

    /**
     * Sets this process builder's Java application arguments. Any previously
     * set arguments will be lost. The list <i>will</i> be copied in the
     * process, to prevent any subsequent indeterminable behavior of other
     * argument list mutators of this builder. Therefore subsequent changes to
     * the provided <tt>args</tt> list will <i>not</i> be reflected in the state
     * of the builder.
     * 
     * @param args
     *            application arguments to set. May not be <code>null</code>.
     * @return this Java process builder
     * @throws NullPointerException
     *             if <tt>args</tt> is <code>null</code>
     */
	public JavaProcessBuilder applicationArguments(List<String> args) {
	    notNull("args", args);
        this.applicationArgs = new ArrayList<String>(args);
        return this;
    }
	
    /**
     * Sets this process builder's Java application arguments. Convenience
     * method for setting the arguments as array of strings.
     * 
     * @param args
     *            - application arguments to set. May not be <code>null</code>.
     * @return this Java process builder
     * @throws NullPointerException
     *             if <tt>args</tt> is <code>null</code>
     * @see #applicationArguments(List)
     */
    public JavaProcessBuilder applicationArguments(String... args) {
        return applicationArguments(Arrays.asList(args));
    }

    /**
     * Add new argument to this process builder's Java application argument
     * list. This will preserve any previous arguments.
     * 
     * @param arg argument to add
     * @return this {@link JavaProcessBuilder} instance
     * @throws NullPointerException
     *             if <tt>arg</tt> is <code>null</code>
     */
    public JavaProcessBuilder addApplicationArgument(String arg) {
        notNull("arg", arg);
        this.applicationArgs.add(arg);
        return this;
    }

    /**
     * Add <tt>args</tt> to this process builder's Java application argument
     * list. Convenience method for adding the arguments as array of strings.
     * 
     * @param args
     *            array of strings to add to application arguments
     * @return this {@link JavaProcessBuilder} instance
     * @throws NullPointerException
     *             if <tt>args</tt> is <code>null</code>
     * @see #addApplicationArguments(List)
     */
    public JavaProcessBuilder addApplicationArguments(String... args) {
        return addApplicationArguments(Arrays.asList(args));
    }

    /**
     * Add <tt>args</tt> to this process builder's Java application argument
     * list. This method <i>will</i> preserve the previously set arguments. The
     * list appended to existing list of arguments, so any subsequent change to
     * <code>args</code> will <i>not</i> be reflected in the state of the
     * builder.
     * 
     * @param args
     *            list of arguments to add to this builder's application
     *            arguments
     * @return this builder instance
     * @throws NullPointerException
     *             if <tt>args</tt> is <code>null</code>
     */
    public JavaProcessBuilder addApplicationArguments(List<String> args) {
        notNull("args", args);
        this.applicationArgs.addAll(args);
        return this;
    }

    /**
     * Return this Java process builder's application arguments. The returned
     * list is <i>not</i> a copy, subsequent updates to the list <i>will</i> be
     * reflected in the state of this builder, unless the argument list will be
     * discarded by means of a call to {@link #applicationArguments(List)} or
     * {@link #applicationArguments(String...)}.
     * 
     * @return this process builder's Java application arguments
     */
    public List<String> applicationArguments() {
        return this.applicationArgs;
	}
	
	
	/**
	 * Mutable environment map for the new Java process.
	 * 
	 * @return environment map
	 * @see ProcessBuilder#environment()
	 */
	public Map<String,String> environment() {
	    return builder.environment();
	}

    /**
     * <p>
     * Mutable map of system properties that will be added to the new Java
     * process. Once obtained by the client, this map may be filled by key-value
     * pairs reflecting the desired system properties. Any changes done to the
     * map obtained by this method will be reflected in the state of this
     * builder.
     * 
     * <p>
     * The properties map does not support <code>null</code> key or values. In
     * case the client attempts to put such entries into the map, an exception
     * will be raised.
     * 
     * <p>
     * <b>NOTE</b> the system properties specified in this map will be
     * transferred to the Java process started by this builder IN ADDITION to
     * the default default system properties provided by the JVM (see
     * {@link System#getProperties()}). It is not possible to remove those
     * default properties or override them in the target Java process by
     * manipulating this map.
     * 
     * @return system properties map
     * @see System#getProperties()
     */
    public Map<String,String> systemProperties() {
        return systemProperties;
    }

    /**
     * <p>
     * Set system property to that will be added to the new Java process. If
     * this builder already contains system property with given <code>key</code>,
     * its value will be overridden with the new one.
     * 
     * <p>
     * <code>null</code> keys or values are not allowed and will raise an
     * exception.
     * 
     * @param key
     *            property key
     * @param value
     *            property value
     * @return this builder instance
     * 
     * @throws NullPointerException
     *             if <tt>key</tt> or <tt>value</tt> is <code>null</code>
     * 
     * @see #systemProperties()
     * @see System#getProperties()
     */
    public JavaProcessBuilder setSystemProperty(String key, String value) {
        systemProperties.put(key, value);
        return this;
    }
	
	/**
     * Set value of the 'redirect error stream'
     * flag of this builder.
     * 
     * @param redirect
     *              - the 'redirect error stream' flag
     * @return this Java process builder instance
     * @see ProcessBuilder#redirectErrorStream(boolean)
     */
    public JavaProcessBuilder redirectErrorStream(boolean redirect) {
        builder.redirectErrorStream(redirect);
        return this;
    }
	
	
    
	/**
	 * Current value of the 'redirect error stream'
	 * flag of this builder.
	 * 
	 * @return the 'redirect error stream' flag
	 * @see ProcessBuilder#redirectErrorStream()
	 */
	public boolean redirectErrorStream() {
	    return builder.redirectErrorStream();
	}


    /**
	 * Set max-memory JVM arg for the new Java process.
	 *
	 * @param arg mem arg instance specifying the amount of memory to set
	 * @return this builder instance
     * @see <a href="http://java.sun.com/docs/books/jvms/second_edition/html/Overview.doc.html#15730">JVM specification $3.5.3</a>
	 */
    public JavaProcessBuilder maxMemory(MemArg arg) {
        this.jvm.maxMemoryArg().validate(arg);
        this.maxMemory = arg;
        return this;
    }

    /**
	 * Get current max-memory JVM arg value for the new Java process.
	 *
	 * @return current max-arg value of this builder, or <code>null</code>, if not set
     * @see <a href="http://java.sun.com/docs/books/jvms/second_edition/html/Overview.doc.html#15730">JVM specification $3.5.3</a>
	 */
    public MemArg maxMemory() {
        return this.maxMemory;
    }

	/**
	 * Set min-memory JVM arg for the new Java process.
	 *
	 * @param arg mem arg instance specifying the amount of memory to set
	 * @return this builder instance
     * @see <a href="http://java.sun.com/docs/books/jvms/second_edition/html/Overview.doc.html#15730">JVM specification $3.5.3</a>
	 */
	public JavaProcessBuilder minMemory(MemArg arg) {
        this.jvm.minMemoryArg().validate(arg);
	    this.minMemory = arg;
	    return this;
	}

	
	/**
	 * Get current min-memory JVM arg value for the new Java process.
	 *
	 * @return current min-arg value of this builder, or <code>null</code>, if not set
     * @see <a href="http://java.sun.com/docs/books/jvms/second_edition/html/Overview.doc.html#15730">JVM specification $3.5.3</a>
	 */
	public MemArg minMemory() {
	    return this.minMemory;
	}

	
	/**
	 * Set maximum stack size of the JVMs started by this builder.
	 * 
	 * @param size new size
	 * @return this builder instance
     * @see <a href="http://java.sun.com/docs/books/jvms/second_edition/html/Overview.doc.html#6654">JVM specification $3.5.2</a>
	 */
	public JavaProcessBuilder maxStackSize(MemArg size) {
	    this.jvm.maxStackSizeArg().validate(size);
        this.maxStackSize = size;
	    return this;
	}
	
	/**
     * Maximum stack size of the JVMs started by this builder.
	 *
	 * @return current maximum stack size of this builder
     * @see <a href="http://java.sun.com/docs/books/jvms/second_edition/html/Overview.doc.html#6654">JVM specification $3.5.2</a>
	 */
	public MemArg maxStackSize() {
	    return this.maxStackSize;
	}


    /**
     * <p>
     * Set a custom JVM arg type and value to this builder's arguments. It is assumed that <tt>argType</tt>
     * belongs to the underlying JVM implementation.
     *
     * @param argType
     *              - argument type. Cannot be <code>null</code> and must belong to this builder's JVM
     * @param argValue
     *              - argument value. Must not be <code>null</code> and must be a valid argument value for the given
     *                <tt>argType</tt>
     * @param <T> type of the argument representation
     * @return this builder instance
     *
     * @throws NullPointerException if any of the method arguments is <code>null</code>
     * @throws IllegalArgumentException if <tt>argType</tt> does not belong to this builder's underlying JVM
     * @throws InvalidJvmArgException if <tt>argValue</tt> fails validation by the <tt>argType</tt>
     *
     * @see jpb.JvmArg
     * @see jpb.JvmArg#isOf(Jvm)
     * @see jpb.JvmArg#validate(Object)
     */
    public <T> JavaProcessBuilder customArg(JvmArg<T> argType, T argValue) {
        if (!argType.isOf(jvm)) {
            throw new IllegalArgumentException("JvmArg <" + argType + "> is not supported by the underlying Jvm: " + jvm);
        }
        argType.validate(argValue);
        this.customArgs.put(argType, argValue);
        return this;
    }

}

